JWT의 서명과 암호화를 혼동하면 안 되는 이유

JWT의 서명과 암호화를 혼동하면 안 되는 이유

한눈에 보기

서명은 발급자가 만든 토큰이며 내용이 바뀌지 않았음을 검증한다. 기밀성이 필요하면 민감 정보를 payload에 넣지 않거나 별도 암호화 규격을 사용한다.

목차

왜 이 문제가 생기는가

JWT 문자열은 길고 무작위처럼 보여 암호화된 데이터로 오해하기 쉽다. 하지만 웹 인증에서 흔히 보는 header.payload.signature 형태는 대개 서명된 JWS다. header와 payload는 Base64URL 디코딩만으로 읽을 수 있고, signature가 발급자와 변조 여부를 검증한다.

이 차이를 놓치면 두 종류의 문제가 생긴다. 개인정보를 payload에 넣어 사용자와 로그 수집 시스템에 노출하거나, 반대로 payload를 decode한 결과만 믿고 인증을 통과시키는 것이다. JWT를 안전하게 쓰려면 “토큰 형식을 파싱했다”와 “이 토큰을 현재 API가 신뢰해도 된다” 사이의 검증 단계를 분리해야 한다.

이 글의 예제에 관하여

issuer, audience, 사용자 ID, 키 ID는 검증 흐름을 설명하기 위한 가상 값이다. 예시 비밀 키나 토큰은 실제 인증에 사용할 수 없다.

JWT, JWS, JWE 관계부터 구분하기

JWT는 claim을 JSON 객체로 표현하는 토큰 형식이다. JWT는 JWS 구조로 서명되거나 JWE 구조로 암호화될 수 있고, 필요하면 중첩될 수도 있다.

용어 주된 목적 내용 노출
JWT claim 집합을 표현 감싸는 방식에 따라 다름
JWS 서명 또는 MAC으로 무결성·발급자 검증 payload를 읽을 수 있음
JWE 암호화로 기밀성 제공 올바른 키 없이는 읽지 못함

따라서 “JWT는 암호화되어 있다”와 “JWT는 항상 서명되어 있다”는 말 모두 일반화할 수 없다. 애플리케이션 계약이 어떤 JOSE 형식을 요구하는지 명시해야 한다. 이 글은 API access token에서 흔한 compact JWS를 중심으로 설명한다.

서명된 JWT의 세 부분

compact JWS는 점으로 나뉜 세 부분으로 보인다.

base64url(protected header)
.
base64url(payload)
.
base64url(signature)

header와 payload를 사람이 읽기 쉽게 풀면 다음과 비슷하다.

{
  "alg": "RS256",
  "typ": "at+jwt",
  "kid": "signing-key-2026-09"
}
{
  "iss": "https://auth.example.test",
  "sub": "user-42",
  "aud": "documents-api",
  "scope": "documents:read",
  "iat": 1788282000,
  "exp": 1788282900,
  "jti": "token-example-901"
}

signature는 일반적으로 인코딩된 header와 payload를 결합한 입력에 대해 계산한다. payload 한 글자만 바꾸어도 기존 signature 검증이 실패해야 한다. 다만 서명은 payload를 가리지 않으므로 브라우저 개발자 도구, proxy 로그, 토큰을 받은 시스템 모두 내용을 읽을 수 있다.

decode와 verify는 전혀 다르다

decode는 Base64URL을 풀고 JSON을 파싱하는 작업이다. 키가 필요하지 않다.

function decodePayloadWithoutTrust(token: string): unknown {
  const [, encodedPayload] = token.split(".");
  return JSON.parse(
    Buffer.from(encodedPayload, "base64url").toString("utf8"),
  );
}

공격자는 payload를 직접 만든 뒤 원하는 role을 넣을 수 있다.

{
  "sub": "attacker",
  "role": "admin"
}

따라서 다음과 같은 코드는 인증이 아니다.

// 위험한 예: 서명과 claim을 검증하지 않는다.
const claims = decodePayloadWithoutTrust(token);
request.userId = claims.sub;

verify는 신뢰하는 키와 허용 알고리즘으로 signature를 검사하고, 이어서 issuer·audience·시간 조건 같은 애플리케이션 claim 정책을 확인해야 한다.

const claims = await tokenVerifier.verify(token, {
  algorithms: ["RS256"],
  issuer: "https://auth.example.test",
  audience: "documents-api",
  requiredClaims: ["sub", "iat", "exp"],
  clockToleranceSeconds: 30,
});

위 코드는 특정 라이브러리 API가 아니라 필요한 정책을 표현한 예시다. 실제 라이브러리가 어떤 검사를 기본으로 하고 어떤 옵션을 명시해야 하는지 문서와 테스트로 확인한다.

알고리즘은 서버 정책으로 고정한다

JWT header의 alg는 토큰을 만든 쪽이 제공하는 입력이다. 서버가 header 값을 보고 아무 알고리즘이나 자동 선택하면 알고리즘 혼동 공격의 여지를 만든다. 검증자는 endpoint가 허용하는 알고리즘 목록을 외부 설정으로 고정해야 한다.

const accessTokenPolicy = {
  allowedAlgorithms: new Set(["RS256"]),
  expectedIssuer: "https://auth.example.test",
  expectedAudience: "documents-api",
};

function assertAllowedAlgorithm(header: { alg?: string }) {
  if (!header.alg || !accessTokenPolicy.allowedAlgorithms.has(header.alg)) {
    throw new Error("TOKEN_ALGORITHM_NOT_ALLOWED");
  }
}

대칭 MAC과 공개키 서명은 키 의미가 다르다. 토큰 종류별로 허용 알고리즘과 키 저장소를 분리하고, none이나 예상하지 않은 알고리즘을 거부한다. 자체 암호 코드를 만들기보다 유지보수되는 JOSE 라이브러리를 사용하되 정책 설정은 애플리케이션 책임이다.

서명 뒤에도 claim을 검증해야 한다

signature가 유효하다는 것은 해당 키로 만들어졌고 서명 입력이 바뀌지 않았다는 뜻이다. 현재 API에서 사용할 수 있다는 뜻까지 자동으로 포함하지 않는다.

claim 확인할 질문
iss 우리가 신뢰하는 발급자인가?
aud 이 토큰의 대상에 현재 API가 포함되는가?
exp 만료 시각 전인가?
nbf 아직 사용하면 안 되는 토큰인가?
iat 발급 시각이 비정상적이지 않은가?
sub 주체 식별자가 이 issuer 문맥에서 유효한가?
jti 필요하다면 취소·재사용 추적에 사용할 수 있는가?
scope 요청한 작업에 필요한 권한이 있는가?

다른 서비스용으로 정상 발급된 토큰을 현재 API가 받아들이면 confused deputy 문제가 생길 수 있다. aud 검증은 “서명 키가 같으니 괜찮다”는 잘못된 신뢰 확장을 막는다.

시간 claim에는 제한된 clock skew 허용이 필요할 수 있지만, 지나치게 큰 여유를 주면 실질적인 토큰 수명이 늘어난다. 서버 시각 동기화와 함께 작은 정책 값으로 관리한다.

민감한 정보를 payload에 넣지 않는다

서명된 payload는 누구나 읽을 수 있고 토큰은 여러 계층을 지난다.

flowchart LR
    A[Auth Server] --> B[Browser or App]
    B --> C[API Gateway]
    C --> D[Application API]
    C -. 잘못된 설정 .-> L[Access Log]

비밀번호, 주민등록번호, refresh token, 내부 비밀 키를 payload에 넣지 않는다. 사용자 이메일처럼 업무상 필요해 보이는 정보도 토큰을 받은 모든 시스템에 전달할 필요가 있는지 검토한다. 가능하면 최소 식별자와 권한 claim만 넣고 최신 프로필은 서버에서 조회한다.

JWT를 JWE로 암호화할 수 있지만 키 배포, 복호화 주체, 교체, nested token 검증 순서가 추가된다. 단지 payload에 민감 정보를 넣기 위해 암호화 토큰을 도입하기보다 처음부터 민감 정보를 토큰에서 제외하는 것이 더 단순할 수 있다.

키 교체와 kid 처리

공개키 기반 서명에서는 여러 검증 키를 동시에 제공하며 점진적으로 교체할 수 있다. header의 kid는 검증자가 후보 키를 찾도록 돕는다.

{
  "keys": [
    { "kid": "signing-key-2026-08", "kty": "RSA", "use": "sig" },
    { "kid": "signing-key-2026-09", "kty": "RSA", "use": "sig" }
  ]
}

kid 역시 신뢰하지 않은 입력이다. 이를 파일 경로나 SQL 문자열에 직접 연결하지 않고, 신뢰하는 key set 안에서 정확히 일치하는 ID를 찾는다. 알 수 없는 kid가 왔다고 매번 외부 key endpoint를 동기 호출하면 공격자가 네트워크 부하를 만들 수 있으므로 캐시와 갱신 제한을 둔다.

키 교체 과정에서는 다음 순서를 고려한다.

  1. 새 공개 검증 키를 먼저 배포한다.
  2. 발급자가 새 개인키로 서명하기 시작한다.
  3. 이전 토큰의 최대 수명이 지난 뒤 구 검증 키를 제거한다.

토큰 종류를 서로 바꿔 쓰지 않는다

access token, ID token, 이메일 인증 token은 claim과 대상이 다르다. 모두 JWT 모양이라고 해서 같은 verifier와 endpoint에서 받아들이면 안 된다.

const policies = {
  accessToken: {
    type: "at+jwt",
    audience: "documents-api",
    requiredScope: "documents:read",
  },
  emailVerification: {
    type: "email-verification+jwt",
    audience: "account-service",
    maxLifetimeSeconds: 600,
  },
};

토큰 종류별로 명시적인 typ, audience, issuer, 필수 claim과 키를 분리하면 한 용도의 토큰을 다른 기능에 재사용하는 교차 JWT 혼동을 줄일 수 있다.

검증 체크리스트

JWT를 인증에 사용할 때

  • decode 결과를 인증으로 사용하지 않는가?
  • 허용 알고리즘과 신뢰 키가 서버 정책으로 고정되어 있는가?
  • issuer, audience, expiration과 필수 claim을 검증하는가?
  • 토큰 종류별로 verifier와 claim 정책이 분리되어 있는가?
  • payload에 노출되면 안 되는 정보가 없는가?
  • 알 수 없는 kid와 key refresh 요청에 제한이 있는가?
  • 로그와 오류 추적 시스템에서 토큰 원문을 마스킹하는가?

서명은 발급자가 만든 토큰이며 내용이 바뀌지 않았음을 검증한다. 기밀성이 필요하면 민감 정보를 payload에 넣지 않거나 별도 암호화 규격을 사용한다.

결론

서명된 JWT는 payload의 기밀성을 제공하지 않고, 신뢰한 키가 만든 토큰이며 서명 입력이 변조되지 않았는지를 검증한다. decode와 verify를 구분하고 알고리즘·키·issuer·audience·시간 claim·토큰 종류를 하나의 정책으로 검사해야 한다. 민감한 정보는 서명된 payload에서 제외하고, 키 교체와 로그 마스킹까지 토큰 생명주기에 포함한다.

관련 노트